Skip to content

feat(console): mark immutable resource fields as read-only in edit forms#6

Merged
kitsunoff merged 5 commits into
mainfrom
claude/magical-meitner-7db476
May 26, 2026
Merged

feat(console): mark immutable resource fields as read-only in edit forms#6
kitsunoff merged 5 commits into
mainfrom
claude/magical-meitner-7db476

Conversation

@lexfrei
Copy link
Copy Markdown
Contributor

@lexfrei lexfrei commented May 12, 2026

Summary

Resource fields declared immutable in the schema via x-kubernetes-validations: [{rule: "self == oldSelf"}] are now:

  • Rendered grey-out with the helper text "This field cannot be changed after creation." on edit screens.
  • Overlaid with the persisted value before PUT so the admission server observes no delta on those paths and the CEL rule is never tripped — defence-in-depth against the YAML editor, devtools, or RJSF bugs bypassing the read-only state.

Create flows are untouched.

Acceptance criterion

The companion cozystack/cozystack#2639 marks MariaDB storageClass immutable (itself dependent on cozystack/cozyvalues-gen#24). With all three PRs in place, opening an existing MariaDB instance for edit grays out storageClass, shows the helper text, keeps the rest editable, and the outgoing PUT carries the original storageClass value.

What's in this PR

  • New helpers in apps/console/src/lib/immutable-paths.ts: findImmutablePaths walks the schema tree and returns every path carrying the CEL rule. overlayImmutable copies the original spec's values along those paths into the submitted body. Wildcard semantics: whole-array/whole-map (*-last) → clone from source; per-element nested (*-not-last) → overlay shared indices/keys, keep user adds, respect deletes. Length shrink with a nested-immutable path bails out with a console.warn (admission stays authoritative). Top-level wildcard paths are handled like root-immutable: warn + clone source.
  • apps/console/src/lib/prepare-update.ts — pure wrapper; warns on malformed schema; short-circuits to a clone when persisted spec is null/undefined.
  • SchemaForm accepts immutableMode (enforce | off, default off). When enforcing, walks the schema and emits ui:disabled + ui:help per immutable path. For object maps (additionalProperties) the field itself is marked disabled — per-entry-value semantics is deliberately deferred.
  • sanitizeSchema strips x-kubernetes-validations once it has been harvested. AJV has no CEL evaluator, so the rule is pure noise to the form validator.
  • ApplicationOrderPage and BackupResourceEditPage opt in to enforcement on edit and anchor the overlay source to a mount-time snapshot so a background React-Query refetch can't sneak a different immutable value into the PUT.

Known limitations (pinned with FIXME tests + tracking issues)

Tests

The console workspace already had vitest + jsdom + @testing-library/react on main; this PR adds 7 new test files (immutable-paths walker, overlay edges, prepare-update, SchemaForm immutable rendering, AdditionalPropertiesField asymmetry, keys-order characterization) totalling 85 passing tests. pnpm test runs them all.

Tested manually against a hand-edited MariaDB values.schema.json carrying the rule on storageClass:

  • Edit form: storageClass greyed out, helper text below, every other field editable.
  • YAML editor bypass + Save → PUT body keeps the original storageClass value.
  • Create flow: storageClass fully editable.
  • Resources without the CEL rule: behaviour identical to master.

No-op until cozystack ships the schema annotation

When no schema carries the rule, findImmutablePaths returns [] everywhere, all walkers are no-ops, the overlay is identity, edit pages behave exactly as today. The UI lights up resource-by-resource as upstream annotates immutable fields.

Summary by CodeRabbit

  • New Features

    • Added immutable field enforcement in forms—disabled fields cannot be edited based on schema validation rules.
    • Form submissions now automatically preserve immutable field values from the original data.
    • Added help text to indicate when fields are immutable.
  • Documentation

    • Updated README with a new Test section documenting how to run tests.
  • Tests

    • Added comprehensive test coverage for immutable field detection and form enforcement behavior.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

Caution

Review failed

Failed to post review comments

📝 Walkthrough

Walkthrough

The PR adds Kubernetes-style immutability enforcement to the console: a new immutable-paths module discovers immutable schema paths from x-kubernetes-validations rules, SchemaForm applies ui:disabled/help-text constraints to those paths, prepareUpdateSpec preserves immutable values during updates, and ApplicationOrderPage/BackupResourceEditPage integrate the enforcement into save flows.

Changes

Kubernetes Immutability Enforcement

Layer / File(s) Summary
Immutable Paths Discovery and Overlay Engine
apps/console/src/lib/immutable-paths.ts, apps/console/src/lib/immutable-paths.test.ts
Core module discovers immutable paths from x-kubernetes-validations rules (self == oldSelf) including nested fields, array items (*), and additionalProperties maps. overlayImmutable deep-clones submitted objects and restores immutable values from originals, with guards against unsafe wildcard array overlays. Tests validate deterministic path ordering, composition propagation, wildcard semantics, and edge cases.
Schema Sanitization and CEL Rule Stripping
apps/console/src/lib/keys-order.ts, apps/console/src/lib/keys-order.test.ts
keysOrderToUiSchema parameter type updated to readonly nested arrays. sanitizeSchema now strips x-kubernetes-validations during traversal, preventing CEL rules from appearing in sanitized output. Documentation clarifies immutability is enforced via separate uiSchema mechanism. Tests validate meta-path filtering, nested ordering, property coercions, and complete CEL rule removal.
SchemaForm Immutable Mode UI Enforcement
apps/console/src/components/SchemaForm.tsx, apps/console/src/components/SchemaForm.test.tsx, apps/console/src/components/AdditionalPropertiesField.test.tsx
New optional immutableMode prop ("enforce" or "off") controls whether immutable paths are computed and applied. When enforced, addImmutableReadonly walker sets ui:disabled and ui:help on immutable schema locations with * wildcard support for arrays and maps. Schema parsing refactored into parsedSchema and sanitized schema memos. Tests validate enforce vs. omitted/off modes, whole-array immutability, nested immutability within array items, and additionalProperties behavior.
Update Spec Preparation with Immutable Preservation
apps/console/src/lib/prepare-update.ts, apps/console/src/lib/prepare-update.test.ts
New prepareUpdateSpec function parses OpenAPI schema to find immutable paths and uses overlayImmutable to restore immutable field values from original spec onto submitted spec for PUT requests, with schema parse error handling and console.warn fallback. Includes cloneShallowAsDeep JSON-based cloning utility. Tests cover immutable overlay, non-mutation, absent immutability, and malformed schema handling.
Edit Page Integration and Initial State Capture
apps/console/src/routes/ApplicationOrderPage.tsx, apps/console/src/routes/BackupResourceEditPage.tsx, README.md
ApplicationOrderPage and BackupResourceEditPage capture initial spec on first render via refs, set SchemaForm immutableMode="enforce" during editing, and build submit payloads via prepareUpdateSpec instead of sending form data directly. Both pages now preserve immutable-field values through save cycles. README adds a new "Test" section documenting pnpm test execution with vitest+jsdom tooling.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Suggested reviewers

  • myasnikovdaniil

Poem

🐰 A rabbit hops through schemas deep,
Finding immutable paths to keep,
CEL rules tucked away safe,
Disabled fields in graceful state,
Updates flow with values locked—no escape! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the primary change: detecting immutable resource fields from schema validations and marking them as read-only in edit forms.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/magical-meitner-7db476

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements immutability enforcement in the SchemaForm component by parsing x-kubernetes-validations and applying ui:disabled and ui:help to immutable paths. It introduces a prepareUpdateSpec utility to ensure immutable values are preserved during updates and adds Vitest for testing. The review feedback highlights improvements for the array overlay logic to prevent potential null-value issues, suggests consolidating utility functions like isPlainObject and deepClone for better consistency, and recommends cleaning up redundant code in the update preparation logic.

Comment thread apps/console/src/lib/immutable-paths.ts Outdated
Comment on lines +120 to +131
const sourceArr = Array.isArray(source) ? source : []
const targetArr = Array.isArray(target) ? target : []
const len = Math.max(sourceArr.length, targetArr.length)
const out: unknown[] = []
for (let i = 0; i < len; i++) {
if (i >= sourceArr.length) {
out[i] = deepClone(targetArr[i])
} else {
out[i] = overlayPath(targetArr[i], sourceArr[i], path, depth + 1)
}
}
return out
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current array overlay logic can produce arrays with undefined elements (holes) when the source array is longer than the target array (e.g., when an item is deleted). When serialized to JSON, these holes become null, which may violate the schema or cause unexpected behavior on the API server. Using targetArr.map is a safer and more concise way to handle positional overlays while correctly respecting deletions and additions.

    const sourceArr = Array.isArray(source) ? source : []
    const targetArr = Array.isArray(target) ? target : []
    return targetArr.map((item, i) =>
      i < sourceArr.length
        ? overlayPath(item, sourceArr[i], path, depth + 1)
        : deepClone(item),
    )

Comment on lines +19 to +20
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === "object" && !Array.isArray(v)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Export isPlainObject so it can be reused in SchemaForm.tsx and deepClone for better consistency and code reuse.

Suggested change
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === "object" && !Array.isArray(v)
export const isPlainObject = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === "object" && !Array.isArray(v)

Comment on lines +143 to +154
function deepClone<T>(value: T): T {
if (value === null || value === undefined) return value
if (Array.isArray(value)) return value.map(deepClone) as unknown as T
if (typeof value === "object") {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = deepClone(v)
}
return out as unknown as T
}
return value
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Export deepClone to allow its reuse in prepare-update.ts, and use the isPlainObject helper for a cleaner and more consistent implementation.

Suggested change
function deepClone<T>(value: T): T {
if (value === null || value === undefined) return value
if (Array.isArray(value)) return value.map(deepClone) as unknown as T
if (typeof value === "object") {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = deepClone(v)
}
return out as unknown as T
}
return value
}
export function deepClone<T>(value: T): T {
if (value === null || value === undefined) return value
if (Array.isArray(value)) return value.map(deepClone) as unknown as T
if (isPlainObject(value)) {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) {
out[k] = deepClone(v)
}
return out as unknown as T
}
return value
}

Comment on lines +1 to +5
import {
findImmutablePaths,
overlayImmutable,
type ImmutablePath,
} from "./immutable-paths.ts"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add deepClone to the imports from ./immutable-paths.ts to replace the redundant cloneShallowAsDeep function.

Suggested change
import {
findImmutablePaths,
overlayImmutable,
type ImmutablePath,
} from "./immutable-paths.ts"
import {
findImmutablePaths,
overlayImmutable,
deepClone,
type ImmutablePath,
} from "./immutable-paths.ts"

openAPISchema: string,
): T {
if (original === undefined || original === null) {
return cloneShallowAsDeep(submitted)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the deepClone utility instead of cloneShallowAsDeep.

Suggested change
return cloneShallowAsDeep(submitted)
return deepClone(submitted)

Comment on lines +46 to +49
function cloneShallowAsDeep<T>(value: T): T {
if (value === null || value === undefined) return value
return JSON.parse(JSON.stringify(value)) as T
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This function is redundant as deepClone is already implemented in immutable-paths.ts. Additionally, the name is misleading (it performs a deep clone) and the implementation using JSON.stringify is less efficient and can strip undefined values from objects.

Comment on lines +6 to +10
import {
IMMUTABLE_HELP_TEXT,
findImmutablePaths,
type ImmutablePath,
} from "../lib/immutable-paths.ts"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add isPlainObject to the imports from ../lib/immutable-paths.ts.

Suggested change
import {
IMMUTABLE_HELP_TEXT,
findImmutablePaths,
type ImmutablePath,
} from "../lib/immutable-paths.ts"
import {
IMMUTABLE_HELP_TEXT,
findImmutablePaths,
isPlainObject,
type ImmutablePath,
} from "../lib/immutable-paths.ts"

key: string,
): Record<string, unknown> {
const existing = uiNode[key]
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the isPlainObject helper for better readability and consistency.

Suggested change
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
if (isPlainObject(existing)) {

@lexfrei lexfrei self-assigned this May 13, 2026
@lexfrei lexfrei force-pushed the claude/magical-meitner-7db476 branch 4 times, most recently from 55c8e1e to d51f984 Compare May 14, 2026 14:24
lexfrei added a commit to cozystack/cozystack that referenced this pull request May 15, 2026
Annotate storageClass as immutable across every stateful app whose
chart exposes one — clickhouse, foundationdb, harbor, http-cache,
kafka (Kafka + ZooKeeper), kubernetes (top-level + nodeGroups[]),
mariadb, mongodb, nats, openbao, opensearch, postgres, qdrant,
rabbitmq, redis, vm-disk. Without coverage across the catalogue,
the UI would render storageClass read-only on some apps and
editable on others — a worse mental model than uniform behaviour.

The annotation lands at the schema layer (CEL XValidation rule
'self == oldSelf' on values.schema.json / types.go / embedded
openAPISchema in cozyrds/<app>.yaml). The cozystack aggregated
apiserver does not yet evaluate CEL on application-backed
resources; that is tracked at #2657. The UI
(cozystack/cozystack-ui#6) consumes the marker today.

The PVC-pinning argument is identical for every chart: Kubernetes
fixes a PVC's storageClassName at creation time, so editing this
field on an existing resource never migrated data. The schema
annotation makes that contract explicit.

A refactor in pkg/registry/apps/application/rest.go extracts the
inline schema-build path into buildSpecSchema so it can be
unit-tested. Three regression tests in
rest_spec_schema_test.go pin the contract that
x-kubernetes-validations survives the v1→internal→structural
conversion (loaded from the actual MariaDB ApplicationDefinition
fixture on disk, not a hand-edited mock).

Requires cozystack/cozyvalues-gen with @immutable support — bump
the pre-commit pin in .github/workflows/pre-commit.yml once the
release exists.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
lexfrei added a commit to cozystack/cozystack that referenced this pull request May 15, 2026
Annotate storageClass as immutable across every stateful app whose
chart exposes one — clickhouse, foundationdb, harbor, http-cache,
kafka (Kafka + ZooKeeper), kubernetes (top-level + nodeGroups[]),
mariadb, mongodb, nats, openbao, opensearch, postgres, qdrant,
rabbitmq, redis, vm-disk. Without coverage across the catalogue,
the UI would render storageClass read-only on some apps and
editable on others — a worse mental model than uniform behaviour.

The annotation lands at the schema layer (CEL XValidation rule
'self == oldSelf' on values.schema.json / types.go / embedded
openAPISchema in cozyrds/<app>.yaml). The cozystack aggregated
apiserver does not yet evaluate CEL on application-backed
resources; that is tracked at #2657. The UI
(cozystack/cozystack-ui#6) consumes the marker today.

The PVC-pinning argument is identical for every chart: Kubernetes
fixes a PVC's storageClassName at creation time, so editing this
field on an existing resource never migrated data. The schema
annotation makes that contract explicit.

A refactor in pkg/registry/apps/application/rest.go extracts the
inline schema-build path into buildSpecSchema so it can be
unit-tested. Three regression tests in
rest_spec_schema_test.go pin the contract that
x-kubernetes-validations survives the v1→internal→structural
conversion (loaded from the actual MariaDB ApplicationDefinition
fixture on disk, not a hand-edited mock).

Requires cozystack/cozyvalues-gen with @immutable support — bump
the pre-commit pin in .github/workflows/pre-commit.yml once the
release exists.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
lexfrei added a commit to cozystack/cozystack that referenced this pull request May 15, 2026
Annotate storageClass as immutable across every stateful app whose
chart exposes one — clickhouse, foundationdb, harbor, http-cache,
kafka (Kafka + ZooKeeper), kubernetes (top-level + nodeGroups[]),
mariadb, mongodb, nats, openbao, opensearch, postgres, qdrant,
rabbitmq, redis, vm-disk. Without coverage across the catalogue,
the UI would render storageClass read-only on some apps and
editable on others — a worse mental model than uniform behaviour.

The annotation lands at the schema layer (CEL XValidation rule
'self == oldSelf' on values.schema.json / types.go / embedded
openAPISchema in cozyrds/<app>.yaml). The cozystack aggregated
apiserver does not yet evaluate CEL on application-backed
resources; that is tracked at #2657. The UI
(cozystack/cozystack-ui#6) consumes the marker today.

The PVC-pinning argument is identical for every chart: Kubernetes
fixes a PVC's storageClassName at creation time, so editing this
field on an existing resource never migrated data. The schema
annotation makes that contract explicit.

A refactor in pkg/registry/apps/application/rest.go extracts the
inline schema-build path into buildSpecSchema so it can be
unit-tested. Three regression tests in
rest_spec_schema_test.go pin the contract that
x-kubernetes-validations survives the v1→internal→structural
conversion (loaded from the actual MariaDB ApplicationDefinition
fixture on disk, not a hand-edited mock).

Requires cozystack/cozyvalues-gen with @immutable support — bump
the pre-commit pin in .github/workflows/pre-commit.yml once the
release exists.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
lexfrei added a commit to cozystack/cozystack that referenced this pull request May 15, 2026
Annotate storageClass as immutable across every stateful app whose
chart exposes one — clickhouse, foundationdb, harbor, http-cache,
kafka (Kafka + ZooKeeper), kubernetes (top-level only), mariadb,
mongodb, nats, openbao, opensearch, postgres, qdrant, rabbitmq,
redis, vm-disk. Without coverage across the catalogue the UI would
render storageClass read-only on some apps and editable on others —
a worse mental model than uniform behaviour.

The annotation lands at the schema layer (CEL XValidation rule
'self == oldSelf' on values.schema.json / types.go / embedded
openAPISchema in cozyrds/<app>.yaml). The cozystack aggregated
apiserver does not yet evaluate CEL on application-backed resources;
that is tracked at #2657. The UI
(cozystack/cozystack-ui#6) consumes the marker today.

The PVC-pinning argument is identical for every chart: Kubernetes
fixes a PVC's storageClassName at creation time, so editing this
field on an existing resource never migrated data. The schema
annotation makes that contract explicit.

kubernetes.nodeGroups[name].storageClass is intentionally NOT
annotated: the field is optional and undefaulted, so a strict
'self == oldSelf' rule would block any future attempt to set it on
an existing node group. A richer ratcheting form is needed there —
see docs/storage-immutability.md.

A refactor in pkg/registry/apps/application/rest.go extracts the
inline schema-build path into buildSpecSchema so it can be
unit-tested. Three regression tests in rest_spec_schema_test.go pin
the contract that x-kubernetes-validations survives the
v1->internal->structural conversion (loaded from the actual MariaDB
ApplicationDefinition fixture on disk, not a hand-edited mock).

Each per-app README gains a one-line blockquote pointing to a new
docs/storage-immutability.md that explains the contract and which
consumers enforce it today.

Requires cozystack/cozyvalues-gen with @immutable support — bump the
pre-commit pin in .github/workflows/pre-commit.yml once the release
exists.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei lexfrei marked this pull request as ready for review May 18, 2026 08:05
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/console/src/lib/immutable-paths.ts`:
- Around line 123-147: wildcardArrayLengthChanged currently returns when it sees
the first "*" segment and therefore misses shrink events deeper in the path;
update wildcardArrayLengthChanged so that when you encounter a "*" that is not
the last segment you iterate over the array elements (using subCur and origCur)
up to Math.min(subCur.length, origCur.length) and for each element
recursively/iteratively continue checking the remaining path segments instead of
returning early; if any element-check detects shrink (or if subCur and origCur
themselves are arrays and subCur.length < origCur.length at the current non-last
"*" level) return true, otherwise continue; keep the existing behavior for the
last "*" segment (isLast -> false).

In `@apps/console/src/routes/ApplicationOrderPage.tsx`:
- Around line 50-56: initialSpecRef currently holds the same object reference as
editMode.initialSpec, so in useEffect where you set initialSpecRef.current
(guarded by initialSpecCapturedRef), deep-clone the snapshot before storing it
to prevent in-place nested edits from mutating the captured "original"; update
the useEffect that checks editMode and initialSpecCapturedRef to assign a cloned
copy (use structuredClone(editMode.initialSpec) when available, falling back to
a JSON deep-copy) to initialSpecRef.current so the original snapshot remains
immutable.

In `@apps/console/src/routes/BackupResourceEditPage.tsx`:
- Around line 67-71: The current initialization assigns the same object to both
form state and the immutable snapshot (resource.spec -> setFormData and
initialSpecRef.current), causing shared-mutability issues; fix it by creating a
deep copy when setting formData and initialSpecRef (e.g., clone resource.spec
before assigning) so setFormData(resource.spec) and initialSpecRef.current =
resource.spec are replaced with cloned copies, ensuring initializedRef.current
logic remains the same but both form state and the immutable snapshot reference
independent objects.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3a5e802b-fecd-4b84-a881-0cdf6f2294f0

📥 Commits

Reviewing files that changed from the base of the PR and between fa41573 and d51f984.

📒 Files selected for processing (13)
  • README.md
  • apps/console/src/components/AdditionalPropertiesField.test.tsx
  • apps/console/src/components/SchemaForm.test.tsx
  • apps/console/src/components/SchemaForm.tsx
  • apps/console/src/lib/immutable-paths.test.ts
  • apps/console/src/lib/immutable-paths.ts
  • apps/console/src/lib/keys-order.test.ts
  • apps/console/src/lib/keys-order.ts
  • apps/console/src/lib/prepare-update.test.ts
  • apps/console/src/lib/prepare-update.ts
  • apps/console/src/routes/ApplicationOrderPage.tsx
  • apps/console/src/routes/BackupResourceEditPage.tsx
  • package.json

Comment on lines +123 to +147
function wildcardArrayLengthChanged(
submitted: unknown,
original: unknown,
path: ImmutablePath,
): boolean {
let subCur: unknown = submitted
let origCur: unknown = original
for (let i = 0; i < path.length; i++) {
const seg = path[i]
if (seg === "*") {
// Whole-array immutability (*-last) is fine to overlay even when the
// user changed the length — the semantics is "freeze, replace from
// source", and that's exactly what the *-last branch does.
const isLast = i === path.length - 1
if (isLast) return false
// Per-element-nested immutability (*-not-last) is the case that can
// silently corrupt on SHRINK (we'd re-anchor source values onto the
// user's surviving indices, which may belong to a different element
// than the user thought they kept). On grow the shared indices stay
// put and the new entries past source.length keep the user's values,
// so growth is safe to overlay.
if (Array.isArray(subCur) && Array.isArray(origCur)) {
return subCur.length < origCur.length
}
return false
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle nested wildcard shrink detection before overlaying immutable array-item paths.

wildcardArrayLengthChanged returns on the first "*" segment, so it can miss array shrinkage on deeper wildcard segments. For nested array paths, this can still re-anchor immutable values to wrong indices.

💡 Suggested fix
 function wildcardArrayLengthChanged(
   submitted: unknown,
   original: unknown,
   path: ImmutablePath,
 ): boolean {
-  let subCur: unknown = submitted
-  let origCur: unknown = original
-  for (let i = 0; i < path.length; i++) {
-    const seg = path[i]
-    if (seg === "*") {
-      // Whole-array immutability (*-last) is fine to overlay even when the
-      // user changed the length — the semantics is "freeze, replace from
-      // source", and that's exactly what the *-last branch does.
-      const isLast = i === path.length - 1
-      if (isLast) return false
-      // Per-element-nested immutability (*-not-last) is the case that can
-      // silently corrupt on SHRINK (we'd re-anchor source values onto the
-      // user's surviving indices, which may belong to a different element
-      // than the user thought they kept). On grow the shared indices stay
-      // put and the new entries past source.length keep the user's values,
-      // so growth is safe to overlay.
-      if (Array.isArray(subCur) && Array.isArray(origCur)) {
-        return subCur.length < origCur.length
-      }
-      return false
-    }
-    if (!isPlainObject(subCur) || !isPlainObject(origCur)) return false
-    subCur = (subCur as Record<string, unknown>)[seg]
-    origCur = (origCur as Record<string, unknown>)[seg]
-  }
-  return false
+  const visit = (subCur: unknown, origCur: unknown, depth: number): boolean => {
+    if (depth >= path.length) return false
+    const seg = path[depth]
+
+    if (seg === "*") {
+      const isLast = depth === path.length - 1
+      if (isLast) return false
+      if (!Array.isArray(subCur) || !Array.isArray(origCur)) return false
+      if (subCur.length < origCur.length) return true
+
+      const shared = Math.min(subCur.length, origCur.length)
+      for (let i = 0; i < shared; i++) {
+        if (visit(subCur[i], origCur[i], depth + 1)) return true
+      }
+      return false
+    }
+
+    if (!isPlainObject(subCur) || !isPlainObject(origCur)) return false
+    return visit(
+      (subCur as Record<string, unknown>)[seg],
+      (origCur as Record<string, unknown>)[seg],
+      depth + 1,
+    )
+  }
+
+  return visit(submitted, original, 0)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/lib/immutable-paths.ts` around lines 123 - 147,
wildcardArrayLengthChanged currently returns when it sees the first "*" segment
and therefore misses shrink events deeper in the path; update
wildcardArrayLengthChanged so that when you encounter a "*" that is not the last
segment you iterate over the array elements (using subCur and origCur) up to
Math.min(subCur.length, origCur.length) and for each element
recursively/iteratively continue checking the remaining path segments instead of
returning early; if any element-check detects shrink (or if subCur and origCur
themselves are arrays and subCur.length < origCur.length at the current non-last
"*" level) return true, otherwise continue; keep the existing behavior for the
last "*" segment (isLast -> false).

Comment on lines +50 to +56
const initialSpecRef = useRef<unknown>(editMode?.initialSpec)
const initialSpecCapturedRef = useRef(false)
useEffect(() => {
if (editMode && !initialSpecCapturedRef.current) {
initialSpecCapturedRef.current = true
initialSpecRef.current = editMode.initialSpec
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clone the persisted snapshot before storing it in initialSpecRef.

initialSpecRef currently keeps the same object reference as editable data. If nested edits mutate in place, the “original” snapshot drifts and immutable overlay protection can be bypassed.

Suggested fix
-  const initialSpecRef = useRef<unknown>(editMode?.initialSpec)
+  const initialSpecRef = useRef<unknown>(
+    editMode?.initialSpec === undefined
+      ? undefined
+      : JSON.parse(JSON.stringify(editMode.initialSpec)),
+  )
@@
-      initialSpecRef.current = editMode.initialSpec
+      initialSpecRef.current = JSON.parse(JSON.stringify(editMode.initialSpec))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const initialSpecRef = useRef<unknown>(editMode?.initialSpec)
const initialSpecCapturedRef = useRef(false)
useEffect(() => {
if (editMode && !initialSpecCapturedRef.current) {
initialSpecCapturedRef.current = true
initialSpecRef.current = editMode.initialSpec
}
const initialSpecRef = useRef<unknown>(
editMode?.initialSpec === undefined
? undefined
: JSON.parse(JSON.stringify(editMode.initialSpec)),
)
const initialSpecCapturedRef = useRef(false)
useEffect(() => {
if (editMode && !initialSpecCapturedRef.current) {
initialSpecCapturedRef.current = true
initialSpecRef.current = JSON.parse(JSON.stringify(editMode.initialSpec))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/routes/ApplicationOrderPage.tsx` around lines 50 - 56,
initialSpecRef currently holds the same object reference as
editMode.initialSpec, so in useEffect where you set initialSpecRef.current
(guarded by initialSpecCapturedRef), deep-clone the snapshot before storing it
to prevent in-place nested edits from mutating the captured "original"; update
the useEffect that checks editMode and initialSpecCapturedRef to assign a cloned
copy (use structuredClone(editMode.initialSpec) when available, falling back to
a JSON deep-copy) to initialSpecRef.current so the original snapshot remains
immutable.

Comment on lines 67 to 71
if (resource?.spec && !initializedRef.current) {
initializedRef.current = true
setFormData(resource.spec)
initialSpecRef.current = resource.spec
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid sharing the same resource.spec object between form state and immutable snapshot.

formData and initialSpecRef currently point to the same value. Any in-place mutation can invalidate the immutable overlay source used during submit.

Suggested fix
-      setFormData(resource.spec)
-      initialSpecRef.current = resource.spec
+      const formSpec = JSON.parse(JSON.stringify(resource.spec))
+      const initialSpec = JSON.parse(JSON.stringify(resource.spec))
+      setFormData(formSpec)
+      initialSpecRef.current = initialSpec
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (resource?.spec && !initializedRef.current) {
initializedRef.current = true
setFormData(resource.spec)
initialSpecRef.current = resource.spec
}
if (resource?.spec && !initializedRef.current) {
initializedRef.current = true
const formSpec = JSON.parse(JSON.stringify(resource.spec))
const initialSpec = JSON.parse(JSON.stringify(resource.spec))
setFormData(formSpec)
initialSpecRef.current = initialSpec
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/routes/BackupResourceEditPage.tsx` around lines 67 - 71, The
current initialization assigns the same object to both form state and the
immutable snapshot (resource.spec -> setFormData and initialSpecRef.current),
causing shared-mutability issues; fix it by creating a deep copy when setting
formData and initialSpecRef (e.g., clone resource.spec before assigning) so
setFormData(resource.spec) and initialSpecRef.current = resource.spec are
replaced with cloned copies, ensuring initializedRef.current logic remains the
same but both form state and the immutable snapshot reference independent
objects.

lexfrei added 5 commits May 25, 2026 15:39
Introduce pure helpers used to recognise and enforce Kubernetes-style
immutability rules on resource schemas.

findImmutablePaths(schema) walks an OpenAPI/JSON-schema tree and
returns every path whose subschema carries an
x-kubernetes-validations entry with rule "self == oldSelf". Object
properties contribute named segments; array items and
additionalProperties contribute the wildcard segment "*". oneOf,
anyOf and allOf branches are considered when determining whether a
node is immutable.

overlayImmutable(submitted, original, paths) deep-clones the submitted
value, then for each path copies the value from original. Wildcard
semantics dispatch on the runtime value shape:

  - *-last on an array: replace with a clone of source (whole-array
    immutable).
  - *-last on an object map: replace with a clone of source (whole-map
    immutable).
  - *-not-last on an array: iterate the user's array, restore the
    immutable nested subfield from source at shared indices, keep
    user-added entries verbatim. Length shrinks are detected and the
    overlay is skipped with a console.warn so admission stays the
    authoritative enforcer (index-aligned overlay would otherwise
    silently swap which element the user deleted).
  - *-not-last on an object map: freeze the whole map. The UI marks
    such fields ui:disabled (Add/Remove hidden); the overlay matches
    so YAML-editor and devtools bypasses can't add or rename keys.

A root-level immutable path or a top-level wildcard immutable path
(both pathological schema shapes) replaces submitted wholesale with a
clone of original and logs a warning.

IMMUTABLE_HELP_TEXT centralises the user-facing string consumed by
later UI wiring.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
AJV has no CEL evaluator, so the rule does nothing for JSON-Schema
validation; the UI consults the raw schema once to harvest
immutability paths and then drops the extension so RJSF traversal
stays small.

Also add characterization tests for keysOrderToUiSchema and
sanitizeSchema so existing behaviour (int-or-string coercion,
preserve-unknown-fields → additionalProperties, "Chart Values" →
"Parameters") is locked in alongside the new strip.

Widen keysOrderToUiSchema's parameter to
ReadonlyArray<ReadonlyArray<string>> so call sites can pass
immutable-path-style arrays without casts.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Add an immutableMode prop ("enforce" | "off", default "off"). When
"enforce", every immutable schema path (as reported by
findImmutablePaths) receives ui:disabled=true plus a ui:help string
sourced from IMMUTABLE_HELP_TEXT. RJSF then renders the corresponding
inputs greyed out and surfaces the helper text underneath. The default
keeps every field editable so create flows are unaffected.

Wildcard segments map to RJSF's "items" key for arrays; for object
maps (additionalProperties) the field itself is marked disabled and
the existing AdditionalPropertiesField renderer propagates that to
the nested form, hiding Add/Remove controls.

Parse the raw schema once and derive both the sanitised RJSFSchema
and the immutable-path set from the shared parsed object so the JSON
string isn't decoded twice per render.

Add component-level tests covering:

  - enforce / off / omitted prop branches on scalar fields,
  - per-element-nested-immutable fields inside array items,
  - whole-array immutable shape,
  - the deliberate UI/overlay asymmetry on additionalProperties
    object maps (the whole map is frozen in both UI and overlay).

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
ApplicationOrderPage and BackupResourceEditPage now opt their
SchemaForm into immutableMode="enforce" when in edit flow so the
relevant fields render greyed out with helper text.

On submit, prepareUpdateSpec walks the schema for immutable paths and
copies the original values from the persisted spec into the outgoing
body. The API server therefore observes no delta on those paths and
the CEL immutability check is not triggered, even if the user managed
to bypass the read-only UI (YAML editor, devtools, RJSF bug).

Both pages anchor the overlay source to a mount-time snapshot stored
in a ref so a background React-Query refetch can't sneak a different
immutable value into the PUT. The snapshot capture lives inside
useEffect to keep render side-effect-free.

prepareUpdateSpec is a thin pure wrapper around findImmutablePaths +
overlayImmutable. It short-circuits to a deep clone when the persisted
spec is null/undefined (avoids blanking immutable fields with
undefined) and logs a console.warn when the schema string fails to
parse instead of silently disabling enforcement.

BackupResourceEditPage.handleSubmit also refuses to save when the CRD
schema hasn't loaded yet, mirroring the existing !resource guard.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The workspace already configures vitest + jsdom + @testing-library
in apps/console; the README mentioned only install/dev/build. Add a
one-line Test section pointing at the workspace-wide "pnpm test".

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei lexfrei force-pushed the claude/magical-meitner-7db476 branch from d51f984 to bce9e7a Compare May 25, 2026 12:43
lexfrei added a commit to cozystack/cozystack that referenced this pull request May 25, 2026
Annotate storageClass as immutable across every stateful app whose
chart exposes one — clickhouse, foundationdb, harbor, http-cache,
kafka (Kafka + ZooKeeper), kubernetes (top-level only), mariadb,
mongodb, nats, openbao, opensearch, postgres, qdrant, rabbitmq,
redis, vm-disk. Without coverage across the catalogue the UI would
render storageClass read-only on some apps and editable on others —
a worse mental model than uniform behaviour.

The annotation lands at the schema layer (CEL XValidation rule
'self == oldSelf' on values.schema.json / types.go / embedded
openAPISchema in cozyrds/<app>.yaml). The cozystack aggregated
apiserver does not yet evaluate CEL on application-backed resources;
that is tracked at #2657. The UI
(cozystack/cozystack-ui#6) consumes the marker today.

The PVC-pinning argument is identical for every chart: Kubernetes
fixes a PVC's storageClassName at creation time, so editing this
field on an existing resource never migrated data. The schema
annotation makes that contract explicit.

kubernetes.nodeGroups[name].storageClass is intentionally NOT
annotated: the field is optional and undefaulted, so a strict
'self == oldSelf' rule would block any future attempt to set it on
an existing node group. A richer ratcheting form is needed there —
see docs/storage-immutability.md.

A refactor in pkg/registry/apps/application/rest.go extracts the
inline schema-build path into buildSpecSchema so it can be
unit-tested. Three regression tests in rest_spec_schema_test.go pin
the contract that x-kubernetes-validations survives the
v1->internal->structural conversion (loaded from the actual MariaDB
ApplicationDefinition fixture on disk, not a hand-edited mock).

Each per-app README gains a one-line blockquote pointing to a new
docs/storage-immutability.md that explains the contract and which
consumers enforce it today.

Requires cozystack/cozyvalues-gen with @immutable support — bump the
pre-commit pin in .github/workflows/pre-commit.yml once the release
exists.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
lexfrei added a commit to cozystack/cozystack that referenced this pull request May 25, 2026
Annotate storageClass as immutable across every stateful app whose
chart exposes one — clickhouse, foundationdb, harbor, http-cache,
kafka (Kafka + ZooKeeper), kubernetes (top-level only), mariadb,
mongodb, nats, openbao, opensearch, postgres, qdrant, rabbitmq,
redis, vm-disk. Without coverage across the catalogue the UI would
render storageClass read-only on some apps and editable on others —
a worse mental model than uniform behaviour.

The annotation lands at the schema layer (CEL XValidation rule
'self == oldSelf' on values.schema.json / types.go / embedded
openAPISchema in cozyrds/<app>.yaml). The cozystack aggregated
apiserver does not yet evaluate CEL on application-backed resources;
that is tracked at #2657. The UI
(cozystack/cozystack-ui#6) consumes the marker today.

The PVC-pinning argument is identical for every chart: Kubernetes
fixes a PVC's storageClassName at creation time, so editing this
field on an existing resource never migrated data. The schema
annotation makes that contract explicit.

kubernetes.nodeGroups[name].storageClass is intentionally NOT
annotated: the field is optional and undefaulted, so a strict
'self == oldSelf' rule would block any future attempt to set it on
an existing node group. A richer ratcheting form is needed there —
see docs/storage-immutability.md.

A refactor in pkg/registry/apps/application/rest.go extracts the
inline schema-build path into buildSpecSchema so it can be
unit-tested. Three regression tests in rest_spec_schema_test.go pin
the contract that x-kubernetes-validations survives the
v1->internal->structural conversion (loaded from the actual MariaDB
ApplicationDefinition fixture on disk, not a hand-edited mock).

Each per-app README gains a one-line blockquote pointing to a new
docs/storage-immutability.md that explains the contract and which
consumers enforce it today.

Requires cozystack/cozyvalues-gen with @immutable support — bump the
pre-commit pin in .github/workflows/pre-commit.yml once the release
exists.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
lexfrei added a commit to cozystack/cozystack that referenced this pull request May 25, 2026
Annotate storageClass as immutable across every stateful app whose
chart exposes one — clickhouse, foundationdb, harbor, http-cache,
kafka (Kafka + ZooKeeper), kubernetes (top-level only), mariadb,
mongodb, nats, openbao, opensearch, postgres, qdrant, rabbitmq,
redis, vm-disk. Without coverage across the catalogue the UI would
render storageClass read-only on some apps and editable on others —
a worse mental model than uniform behaviour.

The annotation lands at the schema layer (CEL XValidation rule
'self == oldSelf' on values.schema.json / types.go / embedded
openAPISchema in cozyrds/<app>.yaml). The cozystack aggregated
apiserver does not yet evaluate CEL on application-backed resources;
that is tracked at #2657. The UI
(cozystack/cozystack-ui#6) consumes the marker today.

The PVC-pinning argument is identical for every chart: Kubernetes
fixes a PVC's storageClassName at creation time, so editing this
field on an existing resource never migrated data. The schema
annotation makes that contract explicit.

kubernetes.nodeGroups[name].storageClass is intentionally NOT
annotated: the field is optional and undefaulted, so a strict
'self == oldSelf' rule would block any future attempt to set it on
an existing node group. A richer ratcheting form is needed there —
see docs/storage-immutability.md.

A refactor in pkg/registry/apps/application/rest.go extracts the
inline schema-build path into buildSpecSchema so it can be
unit-tested. Three regression tests in rest_spec_schema_test.go pin
the contract that x-kubernetes-validations survives the
v1->internal->structural conversion (loaded from the actual MariaDB
ApplicationDefinition fixture on disk, not a hand-edited mock).

Each per-app README gains a one-line blockquote pointing to a new
docs/storage-immutability.md that explains the contract and which
consumers enforce it today.

Requires cozystack/cozyvalues-gen with @immutable support — bump the
pre-commit pin in .github/workflows/pre-commit.yml once the release
exists.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
lexfrei added a commit to cozystack/cozystack that referenced this pull request May 25, 2026
Annotate storageClass as immutable across every stateful app whose
chart exposes one — clickhouse, foundationdb, harbor, http-cache,
kafka (Kafka + ZooKeeper), kubernetes (top-level only), mariadb,
mongodb, nats, openbao, opensearch, postgres, qdrant, rabbitmq,
redis, vm-disk. Without coverage across the catalogue the UI would
render storageClass read-only on some apps and editable on others —
a worse mental model than uniform behaviour.

The annotation lands at the schema layer (CEL XValidation rule
'self == oldSelf' on values.schema.json / types.go / embedded
openAPISchema in cozyrds/<app>.yaml). The cozystack aggregated
apiserver does not yet evaluate CEL on application-backed resources;
that is tracked at #2657. The UI
(cozystack/cozystack-ui#6) consumes the marker today.

The PVC-pinning argument is identical for every chart: Kubernetes
fixes a PVC's storageClassName at creation time, so editing this
field on an existing resource never migrated data. The schema
annotation makes that contract explicit.

kubernetes.nodeGroups[name].storageClass is intentionally NOT
annotated: the field is optional and undefaulted, so a strict
'self == oldSelf' rule would block any future attempt to set it on
an existing node group. A richer ratcheting form is needed there —
see docs/storage-immutability.md.

A refactor in pkg/registry/apps/application/rest.go extracts the
inline schema-build path into buildSpecSchema so it can be
unit-tested. Three regression tests in rest_spec_schema_test.go pin
the contract that x-kubernetes-validations survives the
v1->internal->structural conversion (loaded from the actual MariaDB
ApplicationDefinition fixture on disk, not a hand-edited mock).

Each per-app README gains a one-line blockquote pointing to a new
docs/storage-immutability.md that explains the contract and which
consumers enforce it today.

Requires cozystack/cozyvalues-gen with @immutable support — bump the
pre-commit pin in .github/workflows/pre-commit.yml once the release
exists.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Copy link
Copy Markdown

@kitsunoff kitsunoff left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@kitsunoff kitsunoff merged commit f6ff3f2 into main May 26, 2026
3 checks passed
@kitsunoff kitsunoff deleted the claude/magical-meitner-7db476 branch May 26, 2026 09:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants